feat: multi-device SSO V2 + chat V2#178
Merged
Merged
Conversation
…ng service
Wire-compatible with the V2 SSO protocol:
VersionedHandshakeProposal::V2 — emitted by the host via QR carrying
Device { statementAccountId, encryptionPublicKey } and metadata
(HostName / HostVersion / HostIcon / PlatformType / PlatformVersion / Custom)
VersionedHandshakeResponse — answer over Statement Store, ECDH-encrypted
body; inner payload is `EncryptedHandshakeResponseV2 = Pending | Success
| Failed`. Success carries identity_chat_pubkey + identity_sr25519_pubkey
+ 64-byte sr25519 identity_signature.
Pieces:
- scale/handshakeV2.ts — SCALE codecs. V2 is at discriminant 1 (with a
`_v1Reserved: _void` slot to push it past the legacy V1 index 0).
EncryptedHandshakeResponseV2 is a length-dispatched custom codec
(1 byte / 161 bytes / variable str) since the peer's SCALE library
elides the outer enum index for class-wrapped sealed-interface variants.
- v2/topic.ts — pairing topic + channel derivation:
khash(statementAccountId, encryptionPublicKey || "topic"|"channel")
- v2/proposal.ts — encode + build `polkadotapp://pair?handshake=<hex>`.
- v2/envelope.ts — ECDH (P-256) + AES-GCM via @novasamatech/statement-store
createEncryption.
- v2/state.ts — Idle -> Submitted -> Pending(AllowanceAllocation) ->
Success | Failed; forward-only transitions with same-tag idempotence.
- v2/service.ts — orchestrator: subscribes + polls the pairing topic,
SCALE-decodes statements, drives the state machine. Exposes optional
initialProcessedDataHex + onStatementProcessed so callers can
persist byte-level dedupe across reloads (e.g. for proper logout).
Adds rxjs and @polkadot-api/utils to host-papp deps. 60 new tests.
Adds a `## V2 SSO handshake` section covering:
- protocol overview vs V1 (incompat)
- flow diagram (host ↔ peer)
- building the proposal QR via `buildPairingDeeplink`
- driving the handshake via `startPairingV2`, the state machine, and abort
- surviving reloads + proper logout via `initialProcessedDataHex` /
`onStatementProcessed` byte-level dedupe
- topic/channel derivation helpers
- SCALE codec exports table (proposal, response, success, signature payload)
The existing "Authentication and pairing" section is now flagged as the V1
flow and links to the V2 section, so callers can pick the right path.
Per the multi-device chat spec (HackMD Ski9naYdWe), PApp shares the user identity chat keypair with each paired device so the device can decrypt incoming chat traffic addressed to the user identity. Today's HandshakeSuccessV2 wire payload only carries the public half; this commit extends it with a 32-byte raw P-256 private scalar (identityChatPrivateKey) appended after identitySignature. Wire impact: Success payload grows from 161 to 193 bytes. The length- dispatched EncryptedHandshakeResponseV2 codec recognises the new length as Success; older Pending (1 byte) and Failed (variable string) variants keep the same shape. Encryption is unchanged - the outer envelope's ECDH-AES wrap already protects the payload in transit. Mirrored on the responder side (PApp) per the same spec; consumers (paired devices) persist the priv only in OS-keychain-backed secure storage and never forward it.
Initial pass forced HandshakeSuccessV2 to 193 bytes (with the new chat priv field), which broke pairing against PApp builds that haven't yet shipped the multi-device extension - the codec saw 161-byte legacy Success payloads and fell through to the variable-length Failed branch. Now length-dispatched: 161 bytes decodes as legacy Success with identityChatPrivateKey = undefined, 193 bytes decodes as the new extended Success. Encode emits the 193-byte form when a priv key is provided, otherwise falls back to the legacy struct. HandshakeSuccessState.identityChatPrivateKey becomes Uint8Array | undefined so consumers can branch on availability. Send-only V2 paths continue to work without it; inbound chat-request decryption is gated on the field being present. Removes the wire-incompatibility introduced in the previous commit without rolling back the spec-aligned shape; once every PApp build ships the priv key the legacy branch can be retired.
…vate_key The V2 multi-device handshake spec now ships only the user identity chat P-256 private scalar (32 bytes) on the wire; the matching public key is derived locally via P-256 scalar multiplication. Wire format shrinks from 193 to 128 bytes (accountId || identityChatPrivateKey || identitySignature). Legacy 161-byte payloads (encryptionKey || accountId || signature) are still accepted for PApp builds without the multi-device extension. identitySignature now commits to (accountId || derive_pub(identityChatPrivateKey)).
Align the V2 SSO handshake codec with the multi-device spec (https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe). Success body shape: identityAccountId(32) || rootAccountId(32) || identityChatPrivateKey(32) || deviceEncPubKey(65) = 161 bytes (spec v0.2.1) Also accept the 129-byte v0.2 variant (no rootAccountId) emitted by Android's `feature/location-for-handshake`. Surface `rootAccountId: null` in that case — chat does not need it; product-account derivation degrades gracefully. Removed: - `identitySignature` field (multi-device authorisation moves to the user-identity-signed roster events DeviceAdded/DeviceRemoved) - `IDENTITY_SIGNATURE_PAYLOAD_BYTES` export - `HandshakeSuccessV2WithChatPriv` (was the experimental 128-byte shape) Added: - `HandshakeSuccessV2Value` exported type - `decodeEncryptedHandshakeResponseV2` — explicit length-dispatched decoder for the inner plaintext - `deriveIdentityChatPublicKey` — P-256 scalar mult helper - `EncryptedHandshakeResponseV2` now built with native scale-ts `Enum` on the inner discriminant. The peer SCALE library does NOT elide the variant index, so `Pending(AllowanceAllocation)` arrives as `0x00 0x00` and was being misclassified as `Failed("")` by the prior length-only dispatch. `HandshakeSuccessState` now exposes identityAccountId, rootAccountId, identityChatPrivateKey, identityChatPublicKey (derived locally) and deviceEncPubKey. Pairing service decodes via the new `decodeEncryptedHandshakeResponseV2` and logs failure reasons with the raw inner bytes for diagnosis.
V2 multi-device runtime metadata exposes Resources.Consumers fields in camelCase, which crashed `raw.stmt_store_slots.map(...)` in the host-papp identity adapter. Read each field with snake/camel fallback and treat slots as optional. Same defensiveness applied to host-chat's getConsumerInfo. The .papi descriptor types only model snake_case, so widen via `Record<string, unknown>` at the read site.
Adopt the multi-device chat content shape per the chat spec v0.1 (https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe). New MessageContent variants: - chatAccepted (14) — legacy single-device accept payload changed from `_void` to `ChatAcceptedContent { messageId: String }` for iOS V1 backward decode. - _reserved16 (16) — reserved (Android `coinagePayment`, unused on desktop) - deviceAdded (17) — `{ statementAccountId, encryptionPublicKey }` - deviceRemoved (18) — `{ statementAccountId }` - _reserved19 (19) — placeholder so deviceChatAccepted lands at the spec'd index 20. - deviceChatAccepted (20) — `{ requestId, device: DeviceInfo }`. Sent via identity-level session SessionId(B, A) encrypted with K(A,B); identity-level encryption lets all of A's devices decrypt without a per-device envelope. DeviceAdded/Removed use length-prefixed `Bytes()` instead of fixed-size codecs because substrate-sdk-android emits the `AccountId` / `EncodedPublicKey` wrapper types without `@FixedLength`, falling through to length-prefixed `Vec<u8>` on the wire. DeviceInfoContent (used by deviceChatAccepted) stays fixed-size — Android's `DeviceInfoScale` declares `@FixedLength` explicitly.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
`npm ci` in CI requires package-lock.json to be in sync with package.json. The V2 SSO commits added @polkadot-api/utils, rxjs, and verifiablejs to host-papp but the lockfile wasn't refreshed when the branch was first pushed.
kalininilya
marked this pull request as ready for review
May 20, 2026 23:10
`createAuth` / `pappAdapter.sso` is now V2-driven end-to-end with the same
`pairingStatus` / `authenticate()` / `abortAuthentication()` surface as
before — the V2 wire format, ECDH envelope, pairing service, state
machine, topic derivation, and peer-signer capture all run inside the
SDK. The V1 handshake codec and ephemeral-keypair logic are gone;
callers must inject a persistent `DeviceIdentityForPairing` via a thunk
on `createPappAdapter`.
`createPappAdapter` reshape:
- drops `metadata: string` (the V1 metadata URL) — host name / icon /
platform now ride inside `hostMetadata` (which mirrors V2's
`HandshakeMetadata`)
- requires `deviceIdentity: () => Promise<DeviceIdentityForPairing>`
- new `onAuthSuccess`, `initialProcessedDataHex`,
`onPairingStatementProcessed` callbacks for consumer-side
persistence and reload-survival dedupe
`AuthSuccess.peerStatementAccountId` is lifted off
`statement.proof.value.signer` during pairing so device-sync can seed
PApp without a follow-up chain query. Threaded through
`HandshakeSuccessState` and `fromInnerResponse`.
Public surface trimmed to `createAuth` and the types you need to use
it. `startPairingV2`, the state-machine helpers (`idle` / `submitted` /
`advance` / `fromInnerResponse` / `isTerminal` /
`canSubmitV2Statements`), topic / envelope / proposal helpers
(`computePairingTopic` / `computePairingChannel` /
`buildPairingDeeplink` / `encodeProposal` / `decryptResponseEnvelope` /
`deriveIdentityChatPublicKey`), every `Handshake*` /
`*HandshakeResponse*` / `VersionedHandshake*` / `MetadataEntry` /
`MetadataKey` / `Device` SCALE codec, and the `Handshake*State` /
`HandshakeMetadata` / `HandshakeProposalDevice` /
`HandshakeResponseEnvelope` / `Pairing` / `StartPairingDeps` types are
all internal now. `PairingStatus.finished` no longer carries `session`
— read the resolved `AuthSuccess` off `authenticate()` instead.
`host-papp-react-ui`:
- `useAuthStatus` returns `{ status, isSignedIn }` (was `signedInUser`,
which depended on the V1 session shape)
- `Flow.stories.tsx` updated for the new `createPappAdapter` params
523/523 tests pass. `auth.spec.ts` rewritten to drive the V2-backed
`createAuth` end-to-end (success, persistOnSuccess hook, Failed inner
response, abort, debug emits) using a real SCALE-encoded
HandshakeSuccessV2 envelope.
The previous draft mixed two reference frames: some items described the diff vs. 0.7.8 (correct for a changelog), others described the diff vs. an earlier state of this branch where V2 helpers were briefly public (internal to PR #178 development, irrelevant to consumers). The latter read as "no longer something callers need to wire up" / "public surface trimmed" / "fix: EncryptedHandshakeResponseV2 misclassification" — none of those make sense for a consumer upgrading from 0.7.8, where V2 SSO didn't exist publicly at all. Reframed: - Lead Features bullet now states V2 SSO as a fresh capability behind the existing createAuth surface, with the V2 codecs/service/state- machine flagged as SDK internals (not as "no longer public"). - Dropped the "public surface trimmed" Breaking Change — those names were never in 0.7.8. - Dropped the EncryptedHandshakeResponseV2 fix bullet — that was a bug introduced and fixed inside this PR, not visible to 0.7.8 users. - Merged the legacy-payload-removal bullet into the main V1-removed bullet to avoid double-flagging the same break.
…K; restore V1-shape auth surface
The 0.7.x → 0.8.0 migration is now two field renames (`metadata` →
inline `hostMetadata`, `osType`/`osVersion` → `platformType`/
`platformVersion`). Everything else about the auth surface — the
`pairingStatus` / `authenticate()` / `abortAuthentication()` triad,
`PairingStatus.finished.session`, `authenticate()` resolving to
`StoredUserSession | null` — is what it was.
What moved into the SDK:
- New internal `deviceIdentityStore` (encrypted via the same
gcm/blake2b pattern as `UserSecretRepository`). `createPappAdapter`
now defaults `deviceIdentity` to a `StorageAdapter`-backed factory
that generates and persists a fresh identity on first run. Override
with the new optional `deviceIdentity` param if you need a
different backend (Electron Keychain, native secure storage).
- Pairing-topic statement dedupe (`initialProcessedDataHex` /
`onPairingStatementProcessed` in the previous draft) is now
fully internal; the consumer-facing surface drops both.
- On Success, `createAuth` builds a V2-shaped `StoredUserSession` and
writes it + the per-session secrets to `ssoSessionRepository` and
`userSecretRepository` itself. `authenticate()` returns the
persisted `StoredUserSession`. The optional `onAuthSuccess` hook
fires after persistence with `{ session, identityChatPrivateKey }`
so consumers can fan the user-identity bits out to their own
stores (e.g. polkadot-desktop's `deviceIdentityRepository`).
Schema changes:
- `StoredUserSession` gains `identityAccountId?` and
`identityChatPublicKey?` as trailing `Option` fields; the peer
device statement account is exposed via `remoteAccount.accountId`.
`from` decoder wraps in try/catch and returns `[]` on V1-blob
decode failure, so the SDK silently wipes 0.7.x SsoSessions on
first read instead of crashing.
- `UserSecretRepository` gains `identityChatPrivateKey: Bytes(32)`
as a trailing required field. `decode` wraps in try/catch and
returns `null` on V1-blob decode failure.
- `DeviceIdentityForPairing` adds a required `statementAccountSecret`
(64-byte expanded sr25519 secret) so the V1 sessionManager prover
can sign session statements with the device's stable identity.
Public surface stays compact: `createPappAdapter`, `PappAdapter`,
`AuthComponent`, `HostMetadata`, `OnAuthSuccess`, `PairingStatus`,
`DeviceIdentityForPairing`, `StoredUserSession`, `UserSession`,
`Identity`, signing request/response types, `RingVrf*`,
`SS_*_ENDPOINTS`. `AuthSuccess` is gone — `StoredUserSession` carries
the same fields.
`host-papp-react-ui`:
- `useAuthStatus` restores `signedInUser` (reads
`pairingStatus.finished.session`)
- `Flow.stories.tsx` drops the `deviceIdentity` stub — the SDK now
defaults it.
524/524 tests pass. New tests cover internal persistence, the
`onAuthSuccess` hook, and the default-deviceIdentity fallback.
johnthecat
approved these changes
May 22, 2026
johnthecat
added a commit
that referenced
this pull request
Jun 12, 2026
Main (0.8.7) had independently absorbed nearly all of this branch's work via PRs #178/#202/#205/#206/#212/#215/#216, with newer/cleaner versions. Conflict resolution (38 files): - package.json (x14), package-lock.json, CHANGELOG.md, migration doc: took main (newer versions, canonical release history). Dropped the unused `verifiablejs` dep. - All SSO V2, statement-store, handoff-service, and host-chat *impl* files: took main — strictly newer (ECDH session-key derivation #206, RFC-7 entropy #205, statement-store fix #215). The branch's parallel May implementations are superseded. - watchIdentity series: already identical in main (no real conflict). - Kept BRANCH version for two files where it is genuinely ahead: * identity/rpcAdapter.ts — defensive snake_case/camelCase Resources decode (fixes "Unknown user" regression on V2 runtime); a clean superset of main's version. * host-chat/codec/attachment.spec.ts — extra blurhash-thumbnail and NodeEndpoint round-trip tests against a byte-identical codec impl. Verified: build, typecheck, lint (0 errors), 679/679 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Brings multi-device SSO + V2 chat content to
host-pappandhost-chat. The public auth surface stays V1-shaped:createAuth/pappAdapter.ssokeeps thepairingStatus/authenticate()/abortAuthentication()triad,authenticate()still resolves to aStoredUserSession | null, andpairingStatus.finishedstill carriessession. What's new is the protocol underneath — pairing now runs the V2 multi-device handshake.For a 0.7.8 consumer, the migration is two field renames (
metadataURL → inlinehostMetadata,osType/osVersion→platformType/platformVersion). Everything else is a drop-in upgrade.Spec: https://hackmd.io/@1JCaGppGSUqHtJilikYaKw/Ski9naYdWe
Desktop counterpart: paritytech/polkadot-desktop#489.
host-papp— the public surfaceWhat the SDK now owns (was previously consumer-side or part of the original PR)
DeviceIdentityForPairingto the configuredStorageAdapteron first run and reuses it on subsequent launches. Hosts that want a different backend (Electron Keychain, native secure storage) plug it in with an optionaldeviceIdentity: () => Promise<DeviceIdentityForPairing>factory.Successstatements on chain no longer get replayed across launches — the SDK persists the dedupe hex via the sameStorageAdapter. No consumer plumbing.createAuthbuilds a V2-shapedStoredUserSessionand writes it + per-session secrets tossoSessionRepositoryanduserSecretRepository.authenticate()resolves with theStoredUserSession;pairingStatus.finished.sessioncarries the same object.statement.proof.value.signerduring pairing and exposed viasession.remoteAccount.accountId(no follow-up chain query needed for device-sync seeding).Consumer-facing changes vs 0.7.8
metadata: string(URL)hostMetadatahostMetadata: { hostVersion, osType, osVersion }{ hostName?, hostVersion?, hostIcon?, platformType?, platformVersion?, custom? }pairingStatus: { step, … }authenticate(): ResultAsync<StoredUserSession | null, Error>StoredUserSessiongains optional V2 fields (identityAccountId,identityChatPublicKey)abortAuthentication()Public exports
createPappAdapter/PappAdapter,AuthComponent/HostMetadata/OnAuthSuccess/PairingStatus/DeviceIdentityForPairing,UserSession/StoredUserSession/Identity, signing request/response types,RingVrf*,SS_*_ENDPOINTS.V2 internals —
startPairingV2, codecs (Handshake*,*HandshakeResponse*,VersionedHandshake*,MetadataEntry,MetadataKey,Device), the state-machine helpers, topic / envelope / proposal helpers,deriveIdentityChatPublicKey, theHandshake*Statetypes — are all internal to the package.host-papp— what changed under the hoodcreateAuth—startPairingV2is now called only from insidecreateAuth. The caller (or the SDK's default) provides a persistentDeviceIdentityForPairing; the SDK runs the full pairing dance (codec, ECDH envelope, polling fallback, state machine, peer-signer capture) and resolvesauthenticate()with the persistedStoredUserSession.HandshakeSuccessV2wire — spec v0.2.1 161-byte shape:identityAccountId(32) || rootAccountId(32) || identityChatPrivateKey(32) || deviceEncPubKey(65). The 129-byte v0.2 variant emitted by Androidfeature/location-for-handshakeis still accepted (withrootAccountId === null).DeviceAdded/DeviceRemoved); the success body has no per-deviceidentitySignature.EncryptedHandshakeResponseV2uses nativescale-ts Enumon the inner discriminant. The peer SCALE library does NOT elide the inner index, soPending(AllowanceAllocation)arrives as0x00 0x00.HostMetadatareshape to mirror the V2 proposal — name / icon / platform now ride inside the QR proposal instead of being fetched from the V1metadata: stringURL (which is dropped).StoredUserSessiongains optional V2 fields (identityAccountId,identityChatPublicKey).UserSecretRepositorygainsidentityChatPrivateKey. Old V1 blobs decode short and are silently wiped on first read.identity/rpcAdaptertolerates camel/snake-case Resources.Consumers fields (fullUsername/full_username,lastUpdate/last_update,accountId/account_id) — the V2 multi-device runtime metadata emits camelCase.src/sso/auth/scale/handshake.tsis gone, along with the V1 ephemeral-keypair derivation increateAuth.host-chatMessageContentvariants at the spec'd indices 14–20:chatAccepted (14)— legacy single-device accept now carries{ messageId }for iOS V1 backward decode (was_void)._reserved16 (16),_reserved19 (19)— placeholders so subsequent indices land at the spec values.deviceAdded (17){ statementAccountId, encryptionPublicKey }deviceRemoved (18){ statementAccountId }deviceChatAccepted (20){ requestId, device: DeviceInfo }. Sent on the identity-level sessionSessionId(B, A)encrypted withK(A,B)so all of A's devices can decrypt without a per-device envelope.DeviceAdded/DeviceRemoveduse length-prefixedBytes()rather than fixed-size codecs, becausesubstrate-sdk-androidfalls through to length-prefixedVec<u8>for theAccountId/EncodedPublicKeywrapper types (no@FixedLength).accountService.getConsumerInfogets the same camel/snake-case tolerance as host-papp.Backwards compatibility (vs. 0.7.8)
MessageContentvariants 0–13 are preserved on the receive path, butchatAccepted(14) payload shape changed and older clients emitting_voidwill not decode.feature/location-for-handshake) is still accepted.Test plan
npm run buildnpm run typechecknpm run lintnpm test— 524/524 (52 test files);auth.spec.tscovers V2-drivencreateAuthend-to-end (success path, internal persistence,onAuthSuccesshook, defaultdeviceIdentityfallback, Failed inner response, abort, debug emits)polkadot-desktop, branchfeat/multi-device-messaging-and-sync-rebased) typechecks, lints, builds, and unit tests pass against the linked SDKRelease 0.8.0
(See
CHANGELOG.mdon this branch for the full entry. The summary below reflects the diff vs. 0.7.8, the last released version.)What's new
createAuth/pappAdapter.ssosurface. V2 wire format runs inside the SDK.authenticate()still resolves toStoredUserSession | null;pairingStatus.finishedstill carriessession.deviceIdentityfactory).StoredUserSessionexposes the peer device statement account (viaremoteAccount.accountId) and the user identity / chat keys (viaidentityAccountId/identityChatPublicKey).onAuthSuccesshook for consumer post-pairing work (telemetry, custom peer caches).MessageContentvariants at indices 14, 17, 18, 20 for multi-device chat.Breaking (two field renames; nothing else)
createPappAdapterdropsmetadata: string; host name / icon / platform now inhostMetadata.HostMetadata:osType → platformType,osVersion → platformVersion; addhostName?/hostIcon?/custom?.MessageContent.chatAccepted(14) payload changed from_voidto{ messageId: string }.